import { effect, Signal } from "@char/aftercare"; import type { Message, Usage } from "@earendil-works/pi-ai"; import type { AgentState, ConversationView, EntryRecord, InboxState, LiveState, UsageState, } from "@earendil-works/pi-durable"; import type { ProjectIndex } from "@berry/api/projects.ts"; import type { ExtensionStartup } from "@berry/api/extension-startup.ts"; import type { JsonValue } from "@berry/api"; import type { HeaderContext } from "@berry/api/web.ts"; import { userContent } from "@berry/api/images.ts"; import type { ShellState } from "@berry/api/shell.ts"; import { api, connected, watch } from "./client.ts"; import { composer, go } from "./composer.tsx"; import { configurationEditor, type ConfigurationLocation, type ConfigurationSchema, liveChoices, } from "./configuration.tsx"; import { inbox } from "./inbox.tsx"; import { type StoppedInput, stoppedInputs } from "./stopped-inputs.tsx"; import { extensionRenderers, type WebEntry } from "./renderers.ts"; import { headerWidgets } from "./header.ts"; import { extensionView, viewHref, viewRoute } from "./views.ts"; import { openChannel } from "./channel.ts"; import { type ModelInfo, modelKey, type ModelRef, modelSelect } from "./settings.tsx"; import { readWhenVisible } from "./read.ts"; import { transcript } from "./transcript.tsx"; import { scroller } from "./scroller.tsx"; import { branchCommands, describe, type Move } from "./tree.tsx"; import { computed, json, Scope, swap } from "./ui.ts"; /** What every conversation view shares: the installed extensions, the available models, and those enabled. */ export type Shared = { readonly extensions: Signal; readonly models: Signal; readonly enabled: Signal; /** The machines a conversation can work on: this one, then those settings name. */ readonly machines: Signal; readonly projects: Signal; /** Fetch the available models again, as after logging in or out of a provider. */ readonly refreshModels: () => void; }; export type ExtensionInfo = { name: string; directory?: string | null; description: string | null; status: "loaded" | "failed"; /** Why its last load failed. */ error: string | null; /** Whether a version of it runs: one that failed to reload still runs the last that loaded. */ running: boolean; /** The harness's own, rather than one on disk. */ builtin: boolean; alwaysOn: boolean; /** Whether a new conversation starts with it, until one is chosen from before. */ defaultEnabled: boolean; web: WebEntry | null; /** Names it went by before, which old history still calls it. */ formerly: string[]; configuration: ConfigurationSchema | null; /** Its global editable prose default, when it registers any. */ promptProse: { default: string } | null; startup?: ExtensionStartup | null; }; export type CommandInfo = { name: string; extension: string; description: string; /** Runs here, in the browser, rather than on the server. */ run?: () => Promise; }; const EMPTY: readonly never[] = []; const IDLE: LiveState = {}; function selectedNames(agent: AgentState): string[] { const selection = agent.extensions; if (Array.isArray(selection)) return selection; // old conversations inherited subagent without storing its name return [...new Set(["subagent", ...(selection?.add ?? [])])].filter( (name) => !selection?.remove?.includes(name), ); } /** The running extensions this conversation has, selected or always on, with their frontends. */ function withRenderers(installed: readonly ExtensionInfo[], agent: AgentState) { const names = selectedNames(agent); return installed.flatMap(({ name, running, alwaysOn }) => { const renderers = extensionRenderers(name); return running && (alwaysOn || names.includes(name)) && renderers ? [{ name, renderers }] : []; }); } const totalCost = (usage: UsageState | null) => [ ...Object.values(usage?.models ?? {}), ...Object.values(usage?.tools ?? {}), ].reduce((sum, entry) => sum + (entry.cost?.total ?? 0), 0); /** Compact token counts the way pi-coding-agent's footer does. */ const formatTokens = (count: number): string => { if (count < 1000) return String(count); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; return `${Math.round(count / 1000000)}M`; }; /** The context a response saw, as pi's calculateContextTokens. */ const contextTokensOf = (usage: Usage): number => usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; /** Rough chars/4 estimate of one model message, for messages past the last reported usage. */ function estimateMessageTokens(message: Message): number { const content = (message as { content: unknown }).content; if (typeof content === "string") return Math.ceil(content.length / 4); if (!Array.isArray(content)) return 0; let chars = 0; for (const block of content as unknown[]) { if (typeof block === "string") chars += block.length; else if (block !== null && typeof block === "object") { const record = block as Record; chars += typeof record.text === "string" ? record.text.length : JSON.stringify(block).length; } } return Math.ceil(chars / 4); } /** * The latest assistant usage, plus estimated tokens of what followed it: a la * pi-coding-agent's estimateProjectedContextTokens. Aborted and errored responses * carry no valid usage, and all-zero usage means the provider reported nothing. */ function estimateContext( entries: readonly EntryRecord[], ): { tokens: number; latest?: Usage; durationMs?: number } | null { const head = entries.findLast((entry) => entry.head !== undefined); let trailing = 0; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const messages = entry.model ?? []; for (let j = messages.length - 1; j >= 0; j--) { const message = messages[j]; // retained responses measured the context before this head marker if ((head === undefined || entry.id > head.id) && message.role === "assistant") { const candidate = message.usage; if ( message.stopReason !== "aborted" && message.stopReason !== "error" && candidate && contextTokensOf(candidate) > 0 ) { return { tokens: contextTokensOf(candidate) + trailing, latest: candidate, durationMs: message.durationMs, }; } } trailing += estimateMessageTokens(message); } } return head === undefined ? null : { tokens: trailing }; } /** Cache hit rate of one response: its cache reads over its prompt tokens. */ const cacheHitRate = (usage: Usage): number | undefined => { const prompt = usage.input + usage.cacheRead + usage.cacheWrite; return prompt > 0 ? (usage.cacheRead / prompt) * 100 : undefined; }; type HeaderStatus = { cost: number; /** Estimated context tokens, or null before the first response. */ tokens: number | null; /** The model's context window, or 0 when it is not known. */ window: number; /** Latest response's cache hit rate, when it touched the cache. */ hitRate: number | undefined; }; const contextText = (status: HeaderStatus): string => { if (status.tokens === null) { return status.window > 0 ? `?/${formatTokens(status.window)}` : ""; } if (status.window <= 0) return formatTokens(status.tokens); return `${formatTokens(status.tokens)}/${formatTokens(status.window)}`; }; const contextClass = (status: HeaderStatus): string => { if (status.tokens === null || status.window <= 0) return ""; const percent = (status.tokens / status.window) * 100; if (percent > 90) return "ctx-high"; if (percent > 70) return "ctx-warn"; return ""; }; const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; /** As pi-ai's clampThinkingLevel: the level itself if supported, else the next one up, else the next one down. */ function clamp(levels: readonly string[], level: string): string { if (levels.includes(level)) return level; const at = LEVELS.indexOf(level); return ( LEVELS.slice(at + 1).find((l) => levels.includes(l)) ?? LEVELS.slice(0, Math.max(at, 0)).findLast((l) => levels.includes(l)) ?? levels[0] ?? "off" ); } /** The thinking levels the chosen model supports; a level it lacks moves to its nearest. */ export function thinkingSelect( scope: Scope, models: Signal, model: Signal, current: Signal, choose: (level: string) => void, ): HTMLSelectElement { const select = name !== extension.name, ); configure({ extensions: this.checked ? [...current, extension.name] : current, }); }} /> ) as HTMLInputElement; if (!extension.alwaysOn) boxes.set(extension.name, box); const editor = extension.configuration ? configurationEditor( own, tid, extension.name, extension.configuration, saving, liveChoices(own, extension.name, shared), shared.projects, computed(own, [configurationAgent], () => contextOf(extension.name, configurationAgent.get())), ) : null; const fields = editor?.element; if (editor) { editor.element.hidden = !extension.alwaysOn && !selectedNames(agent).includes(extension.name); configurations.set(extension.name, { ...editor, alwaysOn: extension.alwaysOn, }); } return (
{extension.alwaysOn ? [] : ( )} {fields ?? []}
); }); }); const dropdown =
{selected}{toggles}
as HTMLDetailsElement; const commit = () => { for (const editor of configurations.values()) { void editor.commit()?.catch((error) => notify(error.message, true)); } }; const outside = (event: MouseEvent) => { if (!dropdown.open) return; const target = event.target as Node; if (!dropdown.contains(target)) { dropdown.open = false; commit(); } else if (selected.contains(target)) commit(); }; document.addEventListener("click", outside, true); scope.add(() => document.removeEventListener("click", outside, true)); const cost = document.createTextNode(""); const widgets = headerWidgets(); scope.add(widgets.dispose); const views = as HTMLElement; const drawWidgets = () => { const available = withRenderers(shared.extensions.get(), agent); widgets.update( available.flatMap(({ name, renderers }) => renderers.header ? [{ name, create: renderers.header }] : [] ), (extension) => contextOf(extension, agent), ); const open = viewing.get().split("/")[0]; views.replaceChildren( ...available.flatMap(({ name, renderers: { view } }) => view ? [ {view.label} , ] : [] ), ); }; scope.add(viewing.subscribe(drawWidgets).unsubscribe); scope.add(shared.extensions.subscribe(drawWidgets).unsubscribe); drawWidgets(); const context = as HTMLElement; const hit = document.createTextNode(""); const rate = document.createTextNode(""); const totals = ( {cost} {context} {hit} {rate} ) as HTMLElement; const drawTotals = () => { const window = shared.models.get().find((m) => modelKey(m) === model.get()) ?.contextWindow ?? 0; const estimate = estimateContext(entries); const latest = estimate?.latest; const touched = latest !== undefined && (latest.cacheRead > 0 || latest.cacheWrite > 0); const status: HeaderStatus = { cost: totalCost(usage), tokens: estimate?.tokens ?? null, window, hitRate: latest !== undefined && touched ? cacheHitRate(latest) : undefined, }; const durationMs = estimate?.durationMs ?? 0; const perSecond = durationMs > 0 ? (estimate?.latest?.output ?? 0) / (durationMs / 1000) : NaN; cost.data = `$${status.cost.toFixed(4)}`; context.className = contextClass(status); context.textContent = contextText(status); hit.data = status.hitRate === undefined ? "" : ` CH${status.hitRate.toFixed(1)}%`; rate.data = Number.isFinite(perSecond) && perSecond > 0 ? ` ${perSecond.toFixed(1)} tok/s` : ""; }; scope.add(shared.models.subscribe(drawTotals).unsubscribe); scope.add(model.subscribe(drawTotals).unsubscribe); drawTotals(); return { element: (
{modelSelect(scope, shared.models, shared.enabled, model, (chosen) => configure({ model: chosen }))} {thinkingSelect(scope, shared.models, model, thinking, (level) => configure({ thinkingLevel: level }))} {dropdown} {views} {widgets.element} {totals}
), async settle() { await Promise.all([...saves]); }, update(next: { agent: AgentState; usage: UsageState | null; entries: readonly EntryRecord[]; }) { if (next.agent !== agent) { agent = next.agent; configurationAgent.set(agent); const names = selectedNames(agent); selected.textContent = names.join(", ") || "none"; for (const [name, box] of boxes) box.checked = names.includes(name); for (const [name, { element, alwaysOn }] of configurations) { element.hidden = !alwaysOn && !names.includes(name); } // together, so neither select sees the other's old value effect(() => { model.set(agent.model ? modelKey(agent.model) : ""); thinking.set(agent.thinkingLevel ?? "off"); }); drawWidgets(); } if (next.usage !== usage || next.entries !== entries) { usage = next.usage; entries = next.entries; drawTotals(); } }, }; } /** A notice above the composer, which goes after a while. */ export function notifier() { const element =